“复杂度不会消失,只会转移”,真的是这样吗?
转移复杂度是徒劳的
前两天在 lobsters 上看到一篇高赞文章(https://ferd.ca/complexity-has-to-live-somewhere.html),大意就是复杂度是不灭的,只是在不同的部分之间做转移。当然这个很多写过 lib 的人都有类似的体会。要让 lib 的用户简单,写 lib 的人就要做得更复杂。
Complexity has to livesomewhere. If you are lucky, it lives in well-defined places.
文章的推论是要承认复杂度总是在那里的,要放在大家都知道的地方管理好。然而另外一个令人悲伤的消息是 Every abstraction is leaky。至少有两个导致侧漏的常见原因:
运行时的 cost 是藏不住的。两种写法可以完全一样,但是执行起来效率就是会有差异。例如 SQL 写的时候可以完全忽略数据在磁盘上是怎么组织的,性能调优的时候绝对无法对你隐藏实际的数据分布在哪里。
故障的时候,必须要掀起引擎盖。很多工程师乐衷于在故障总结里分享自己是如何找到 Linux kernel 或者硬件驱动的 bug 的。这就说明了,这种掀起引擎盖的难度足以让人为之骄傲。
所以不管你这东西再牛逼,出了故障之后要么是用户自己开盖子来修,然后骂你。要么是等你来修,你又来慢了,然后骂你。然后再默念一遍,复杂度是不灭的,只会转移。
管理复杂度是徒劳的
另外一种复杂叫“我感觉”复杂。其中“我”说明了这个是视角问题。“感觉”说明这个和人脑这种生物CPU的运行模式有关系。最常见的评价是这代码怎么像意大利面条似的。一个经典的说法是 Kent Beck 在《实现模式》一书中提到的 Local Consequence:希望一个改动能够局部化影响。这包括你要做这个修改,需要阅读的代码,或者需要获得的知识边界,能够局限在一个局部。同时也包括你的源代码改动本身,不是一个散落到各个代码仓库,这一点那一点的片段。
此文中有更详细的论述:
https://www.jianshu.com/p/d127b8afc8cb
然而这个愿望是永远不可能达成的。如果目标是任何改动都能实现 Local Consequence ,这是绝无可能实现的。你总是要决定什么和什么放在一起的。不可能一个东西和所有其他东西都放在一起。当代码组织成方便了一类改动的时候,必然会让另外一类改动像是意大利面条似的。比如说:
你可以按照页面组织代码
可以按照数据所有权组织代码
可以按照同一个业务流程组织代码
可以按照同一个区域语言的发行版本组织代码
也可以按照同一个市场的定制版本组织代码。
可以让读同一个变量的代码放一起
也可以让写同一个变量的代码放一起
还可以让无论出于什么神奇的原因,反正这些代码都是我负责,就要把这些代码放一起
这些原则彼此经常是冲突的。
正因为无法让所有改动都实现 Local Consequence,所以得依赖经验去判断发生变化的频率。尽量让高频的改动变成是容易的。但是又怎么能未卜先知,提前知道什么是会高频变化的呢?如果你相信这种花大力气整一个架构,搞出一个笨拙的家伙去预测不靠谱的未来是徒劳的。那么是不是所谓的 Simple Design,堆一堆毕业生按直觉本能,996 堆砌代码逻辑其是更好的策略呢。简单,直接,有效。
真的是这样的吗?
以上所有的现象都是客观存在的。但是我不认为一切都是徒劳的。至少有这么几个理由:
虽然无法做到对任何改动都实现 Local Consequence,但是分层很大程度上仍然是成功的。需要你去了解 tcpip stack 的实现,去了解 Linux timer 的实现的时候是很少的。这决定了转移复杂度仍然是有价值的,只要能大部分时候,能在保持“无知”的情况下节省了时间就是有收益的。只要我们能找到可以转移的复杂度,哪怕是一点,也值得努力去试试。
预测变化并不是主要的难题。而是很多时候,我们明明知道变化经常如何发生,但是仍然无法把变化引起的改动局限在一个局部。例如,我们知道总是有各种促销来改购物车,改结算页,但是就是无法把促销的改动局限在一个局部,而是要到处都改一点。是不是现有的写法导致了某些 consequence 无法 localize 呢?这个现象是一个值得解决的问题。
即便无法让代码的改动局限在一块,让代码读起来更通顺总是可以吧。我们可以看到在理解一个业务的上下文的时候,经常需要跳来跳去的,熟练使用全局搜索。这样的阅读对于人脑是非常不友好的。把“前因后果”局限在一个局部,至少可以让一个新人更快的建立全局观,读懂现状,才好下手改。那是不是现有的写法导致就是不容易把“前因后果”写一起呢?
工具箱里多一个工具总是好的。特别是现有的工具还有那么几个不趁手的时候。
复杂度的消灭
“重复”是邪恶的。消灭重复一方面可以使得改动的 Consequence 更 Local,更不意大利面条。另外一方面,消灭重复可以使得总的代码量更小,复杂度更低。我们可以看到以下几个成功案例:
计算一个神经网络的 forward pass 的代码,和求解梯度的 backward pass 的代码是对称的。同过可微分计算,可以自动使用 forward pass 的代码,“生成”backward pass 的代码。所以 PyTorch 就成功了。
第一遍计算一个变量的值,与其依赖的数据之后,刷新这个变量的代码是类似的。所以我们可以在依赖的数据变化之后,重新跑一遍 getter 的计算函数,重新完整算一遍。所以 vue 基于依赖订阅的原理,获得了成功。
一个界面首屏渲染的代码,与后续局部更新的代码是类似的,有部分重复的地方。所以 react 的用一个 render 函数整体描述,整体重渲染,由框架自动 vdom diff 的做法成功地消除了重复。
一个表单的 CRUD 和另外几个表单的 CRUD 是非常类似的,90% 相同,少量不同。所以各种 Airtable 的产品成功了。
同样一个 RPC 结构体,发送方要定义一遍,接收方也要定义一遍。所以 gRPC 这样的代码生成方案成功了。
如果找不到“重复”之处,那么确实可能只是复杂度的转移,不是你麻烦,就是我麻烦。如果能够找到现有 solution 中内在“重复”之处,就可以搞一个框架,弄一种新写法,把“重复”给干掉了。这部分投入虽然很复杂,但是仍然从整体上是消灭了一部分复杂度的。
多尝试几种写法,至关重要
消灭重复,往往不是在现有写法下抽取一个函数那么简单。我们可以从过去的经验里看到,往往需要整体换一个写法,才能把重复给消灭掉。你在 jQuery 的写法下,再怎么抽取函数,也不能变成 vue。
现有的写法无法经常无法保证读代码是 Local 的,也无法保证高频需求的改动是 Local 的。朝着 Local Consequence 的目标去努力,去换一种写法。
软件架构这门社会科学也许到了某天,就没有什么进步空间了。到了那天可能所有的改进都只是横跳,按下葫芦立马浮起了瓢。毕竟为人脑这个 CPU 做效率优化的生意,不能无止尽的一直优化下去。所有的优化都有尽头,都要受到物理硬件的约束。
但至少今天还远远没有到达那个程度。在整个“写法空间”里,仍然有值得搜索和评价的未知角落。
推荐阅读:
分布式实验室策划的《Kubernetes线上实战训练营》正式上线了。这门课程通过4天线上培训,3个课后大作业,30天课后辅导,把Kubernetes的60多个重要知识点讲给你,并通过实战让你掌握Kubernetes。培训重实战、重项目、更贴近工作,边学边练,10月29日正式开课。
👇 点击下图加入学习👇